from __future__ import annotations

import gzip
import os
import xml.dom.minidom
from typing import TYPE_CHECKING
from typing import BinaryIO
from typing import Callable
from typing import TypedDict

from barcode.version import version

if TYPE_CHECKING:
    from collections.abc import Generator
    from typing import Literal

    from PIL.Image import Image as T_Image
    from PIL.ImageDraw import ImageDraw as T_ImageDraw

    class InternalText(TypedDict):
        start: list
        end: list
        xpos: list
        was_guard: bool

    class Callbacks(TypedDict):
        initialize: Callable | None
        paint_module: Callable
        paint_text: Callable | None
        finish: Callable


try:
    from PIL import Image
    from PIL import ImageDraw
    from PIL import ImageFont
except ImportError:
    import logging

    log = logging.getLogger("pyBarcode")
    log.info("Pillow not found. Image output disabled")
    Image = ImageDraw = ImageFont = None  # type: ignore[assignment]


def mm2px(mm: float, dpi: int) -> float:
    return (mm * dpi) / 25.4


def pt2mm(pt: float) -> float:
    return pt * 0.352777778


def _set_attributes(
    element: xml.dom.minidom.Element,
    **attributes: str,
) -> None:
    for key, value in attributes.items():
        element.setAttribute(key, value)


def create_svg_object(with_doctype: bool = False) -> xml.dom.minidom.Document:
    imp = xml.dom.minidom.getDOMImplementation()
    assert imp is not None
    doctype = imp.createDocumentType(
        "svg",
        "-//W3C//DTD SVG 1.1//EN",
        "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd",
    )
    document = imp.createDocument(None, "svg", doctype if with_doctype else None)
    assert document.documentElement is not None
    _set_attributes(
        document.documentElement, version="1.1", xmlns="http://www.w3.org/2000/svg"
    )
    return document


SIZE = "{0:.3f}mm"
COMMENT = f"Autogenerated with python-barcode {version}"
PATH = os.path.dirname(os.path.abspath(__file__))


class BaseWriter:
    """Baseclass for all writers.

    Initializes the basic writer options. Child classes can add more attributes and can
    set them directly or using ``self.set_options(option=value)``.

    :param initialize: Callback for initializing the inheriting writer.
        Is called: ``callback_initialize(raw_code)``
    :param paint_module:
        Callback for painting one barcode module.
        Is called: ``callback_paint_module(xpos, ypos, width, color)``
    :param paint_text: Callback for painting the text under the barcode.
        Is called: ``callback_paint_text(xpos, ypos)`` using `self.text`
        as text.
    :param finish: Callback for doing something with the completely rendered
        output. Is called: ``return callback_finish()`` and must return the
        rendered output.
    """

    _callbacks: Callbacks
    module_width: float
    module_height: float
    font_path: str
    font_size: float
    quiet_zone: float
    background: str | int
    foreground: str | int
    text: str
    human: str
    text_distance: float
    text_line_distance: float
    center_text: bool
    guard_height_factor: float
    margin_top: float
    margin_bottom: float

    def __init__(
        self,
        initialize: Callable | None,
        paint_module: Callable,
        paint_text: Callable | None,
        finish: Callable,
    ) -> None:
        self._callbacks = {
            "initialize": initialize,
            "paint_module": paint_module,
            "paint_text": paint_text,
            "finish": finish,
        }
        self.module_width = 10
        self.module_height = 10
        self.font_path = os.path.join(PATH, "fonts", "DejaVuSansMono.ttf")
        self.font_size = 10
        self.quiet_zone = 6.5
        self.background = "white"
        self.foreground = "black"
        self.text = ""
        self.human = ""  # human readable text
        self.text_distance = 5
        self.text_line_distance = 1
        self.center_text = True
        self.guard_height_factor = 1.1
        self.margin_top = 1
        self.margin_bottom = 1

    def calculate_size(self, modules_per_line: int, number_of_lines: int) -> tuple:
        """Calculates the size of the barcode in pixel.

        :param modules_per_line: Number of modules in one line.
        :param number_of_lines: Number of lines of the barcode.

        :returns: Width and height of the barcode in pixel.
        """
        width = 2 * self.quiet_zone + modules_per_line * self.module_width
        height = (
            self.margin_bottom + self.margin_top + self.module_height * number_of_lines
        )
        number_of_text_lines = len(self.text.splitlines())
        if self.font_size and self.text:
            height += (
                pt2mm(self.font_size) / 2 * number_of_text_lines + self.text_distance
            )
            height += self.text_line_distance * (number_of_text_lines - 1)
        return width, height

    def save(self, filename: str, output) -> str:
        """Saves the rendered output to `filename`.

        :param filename: Filename without extension.
        :param output: The rendered output.

        :returns: The full filename with extension.
        """
        raise NotImplementedError

    def register_callback(
        self,
        action: Literal["initialize", "paint_module", "paint_text", "finish"],
        callback: Callable,
    ) -> None:
        """Register one of the three callbacks if not given at instance creation.

        :param action: One of 'initialize', 'paint_module', 'paint_text', 'finish'.
        :param callback: The callback function for the given action.
        """
        self._callbacks[action] = callback

    def set_options(self, options: dict) -> None:
        """Sets the given options as instance attributes (only
        if they are known).

        :param options: All known instance attributes and more if the child class
            has defined them before this call.
        """
        for key, val in options.items():
            key = key.lstrip("_")
            if hasattr(self, key):
                setattr(self, key, val)

    def packed(self, line: str) -> Generator[tuple[int, float], str, None]:
        """
        Pack line to list give better gfx result, otherwise in can
        result in aliasing gaps
        '11010111' -> [2, -1, 1, -1, 3]

        This method will yield a sequence of pairs (width, height_factor).

        :param line: A string matching the writer spec (only contain 0 or 1 or G).
        """
        line += " "
        c = 1
        for i in range(len(line) - 1):
            if line[i] == line[i + 1]:
                c += 1
            else:
                if line[i] == "1":
                    yield (c, 1)
                elif line[i] == "G":
                    yield (c, self.guard_height_factor)
                else:
                    yield (-c, self.guard_height_factor)
                c = 1

    def render(self, code: list[str]):
        """Renders the barcode to whatever the inheriting writer provides,
        using the registered callbacks.

        :parameters:
            code : List
                List consisting of a single string matching the writer spec
                (only contain 0 or 1 or G).
        """
        if self._callbacks["initialize"] is not None:
            self._callbacks["initialize"](code)
        ypos = self.margin_top
        base_height = self.module_height
        if len(code) != 1:
            raise NotImplementedError("Only one line of code is supported")
        line = code[0]
        # Left quiet zone is x startposition
        xpos = self.quiet_zone
        bxs = xpos  # x start of barcode
        text: InternalText = {
            "start": [],  # The x start of a guard
            "end": [],  # The x end of a guard
            "xpos": [],  # The x position where to write a text block
            # Flag that indicates if the previous mod was part of an guard block:
            "was_guard": False,
        }
        for mod, height_factor in self.packed(line):
            if mod < 1:
                color = self.background
            else:
                color = self.foreground

                if text["was_guard"] and height_factor == 1:
                    # The current guard ended, store its x position
                    text["end"].append(xpos)
                    text["was_guard"] = False
                elif not text["was_guard"] and height_factor != 1:
                    # A guard started, store its x position
                    text["start"].append(xpos)
                    text["was_guard"] = True

            self.module_height = base_height * height_factor
            # remove painting for background colored tiles?
            self._callbacks["paint_module"](
                xpos, ypos, self.module_width * abs(mod), color
            )
            xpos += self.module_width * abs(mod)
        else:
            if height_factor != 1:
                text["end"].append(xpos)
            self.module_height = base_height

        bxe = xpos
        ypos += self.module_height

        if self.text and self._callbacks["paint_text"] is not None:
            if not text["start"]:
                # If we don't have any start value, print the entire ean
                ypos += self.text_distance
                xpos = bxs + (bxe - bxs) / 2.0 if self.center_text else bxs
                self._callbacks["paint_text"](xpos, ypos)
            else:
                # Else, divide the ean into blocks and print each block
                # in the expected position.
                text["xpos"] = [bxs - 4 * self.module_width]

                # Calculates the position of the text by getting the difference
                # between a guard end and the next start
                text["start"].pop(0)
                for s, e in zip(text["start"], text["end"]):
                    text["xpos"].append(e + (s - e) / 2)

                # The last text block is always put after the last guard end
                text["xpos"].append(text["end"][-1] + 4 * self.module_width)

                ypos += pt2mm(self.font_size)

                # Split the ean into its blocks
                blocks = self.text.split(" ")
                for text_, xpos in zip(blocks, text["xpos"]):
                    self.text = text_
                    self._callbacks["paint_text"](xpos, ypos)

        return self._callbacks["finish"]()

    def write(self, content, fp: BinaryIO) -> None:
        raise NotImplementedError


class SVGWriter(BaseWriter):
    def __init__(self) -> None:
        super().__init__(
            self._init,
            self._create_module,
            self._create_text,
            self._finish,
        )
        self.compress: bool = False
        self.with_doctype: bool = True
        self._document: xml.dom.minidom.Document
        self._root: xml.dom.minidom.Element
        self._group: xml.dom.minidom.Element

    def _init(self, code: list[str]):
        if len(code) != 1:
            raise NotImplementedError("Only one line of code is supported")
        line = code[0]
        width, height = self.calculate_size(len(line), 1)
        self._document = create_svg_object(self.with_doctype)
        assert self._document.documentElement is not None
        self._root = self._document.documentElement
        attributes = {
            "width": SIZE.format(width),
            "height": SIZE.format(height),
        }
        _set_attributes(self._root, **attributes)
        if COMMENT:
            self._root.appendChild(self._document.createComment(COMMENT))
        # create group for easier handling in 3rd party software
        # like corel draw, inkscape, ...
        group = self._document.createElement("g")
        attributes = {"id": "barcode_group"}
        _set_attributes(group, **attributes)
        self._group = self._root.appendChild(group)
        if self.background is not None:
            background = self._document.createElement("rect")
            attributes = {
                "width": "100%",
                "height": "100%",
                "style": f"fill:{self.background}",
            }
            _set_attributes(background, **attributes)
            self._group.appendChild(background)

    def _create_module(self, xpos, ypos, width, color):
        # Background rect has been provided already, so skipping "spaces"
        if color != self.background:
            element = self._document.createElement("rect")
            attributes = {
                "x": SIZE.format(xpos),
                "y": SIZE.format(ypos),
                "width": SIZE.format(width),
                "height": SIZE.format(self.module_height),
                "style": f"fill:{color};",
            }
            _set_attributes(element, **attributes)
            self._group.appendChild(element)

    def _create_text(self, xpos, ypos):
        # check option to override self.text with self.human (barcode as
        # human readable data, can be used to print own formats)
        barcodetext = self.human if self.human != "" else self.text
        for subtext in barcodetext.split("\n"):
            element = self._document.createElement("text")
            attributes = {
                "x": SIZE.format(xpos),
                "y": SIZE.format(ypos),
                "style": (
                    f"fill:{self.foreground};"
                    f"font-size:{self.font_size}pt;text-anchor:middle;"
                ),
            }
            _set_attributes(element, **attributes)
            text_element = self._document.createTextNode(subtext)
            element.appendChild(text_element)
            self._group.appendChild(element)
            ypos += pt2mm(self.font_size) + self.text_line_distance

    def _finish(self) -> bytes:
        if self.compress:
            return self._document.toxml(encoding="UTF-8")

        return self._document.toprettyxml(
            indent=4 * " ", newl=os.linesep, encoding="UTF-8"
        )

    def save(self, filename: str, output) -> str:
        if self.compress:
            _filename = f"{filename}.svgz"
            with gzip.open(_filename, "wb") as f:
                f.write(output)
                f.close()
        else:
            _filename = f"{filename}.svg"
            with open(_filename, "wb") as f:
                f.write(output)
        return _filename

    def write(self, content, fp: BinaryIO) -> None:
        """Write `content` into a file-like object.

        Content should be a barcode rendered by this writer.
        """
        fp.write(content)


if Image is None:
    ImageWriter: type | None = None
else:

    class ImageWriter(BaseWriter):  # type: ignore[no-redef]
        format: str
        mode: str
        dpi: int

        def __init__(
            self,
            format: str = "PNG",
            mode: str = "RGB",
            dpi: int = 300,
        ) -> None:
            """Initialise a new write instance.

            :params format: The file format for the generated image. This parameter can
                take any value that Pillow accepts.
            :params mode: The colour-mode for the generated image. Set this to RGBA if
                you wish to use colours with transparency.
            """
            super().__init__(
                self._init,
                self._paint_module,
                self._paint_text,
                self._finish,
            )
            self.format = format
            self.mode = mode
            self.dpi = dpi
            self._image: T_Image
            self._draw: T_ImageDraw

        def _init(self, code: list[str]) -> None:
            if ImageDraw is None:
                raise RuntimeError("Pillow not found. Cannot create image.")
            if len(code) != 1:
                raise NotImplementedError("Only one line of code is supported")
            line = code[0]
            width, height = self.calculate_size(len(line), 1)
            size = (int(mm2px(width, self.dpi)), int(mm2px(height, self.dpi)))
            self._image = Image.new(self.mode, size, self.background)
            self._draw = ImageDraw.Draw(self._image)

        def _paint_module(self, xpos: float, ypos: float, width: float, color):
            size = [
                (mm2px(xpos, self.dpi), mm2px(ypos, self.dpi)),
                (
                    mm2px(xpos + width, self.dpi) - 1,
                    mm2px(ypos + self.module_height, self.dpi),
                ),
            ]
            self._draw.rectangle(size, outline=color, fill=color)

        def _paint_text(self, xpos, ypos):
            assert ImageFont is not None

            # check option to override self.text with self.human (barcode as
            # human readable data, can be used to print own formats)
            barcodetext = self.human if self.human != "" else self.text

            font_size = int(mm2px(pt2mm(self.font_size), self.dpi))
            if font_size <= 0:
                return
            font = ImageFont.truetype(self.font_path, font_size)
            for subtext in barcodetext.split("\n"):
                pos = (
                    mm2px(xpos, self.dpi),
                    mm2px(ypos, self.dpi),
                )
                self._draw.text(
                    pos, subtext, font=font, fill=self.foreground, anchor="md"
                )
                ypos += pt2mm(self.font_size) / 2 + self.text_line_distance

        def _finish(self) -> T_Image:
            return self._image

        def save(self, filename: str, output) -> str:
            filename = f"{filename}.{self.format.lower()}"
            output.save(filename, self.format.upper())
            return filename

        def write(self, content, fp: BinaryIO) -> None:
            """Write `content` into a file-like object.

            Content should be a barcode rendered by this writer.
            """
            content.save(fp, format=self.format)
